Skip to content

One TurboQuant lookup implementation instead of six - #55

Merged
marcobambini merged 1 commit into
mainfrom
perf/unify-turboquant-lut
Aug 24, 2026
Merged

One TurboQuant lookup implementation instead of six#55
marcobambini merged 1 commit into
mainfrom
perf/unify-turboquant-lut

Conversation

@marcobambini

Copy link
Copy Markdown
Member

The audit listed this as "the LUT backends are not actually vectorised — each one
gathers four scalar table lookups into a stack array and does a single vector add"
.
The description was accurate. The conclusion was wrong, and measuring it is what
showed why.

There is nothing here for SIMD to do

The TurboQuant scan is one table lookup per row. NEON has no gather instruction at
all, and on real data the loop already runs at about one lookup per cycle — the
load unit's limit. What the per-backend versions were actually buying was four
parallel float lanes instead of one serial double accumulator.

Four independent double accumulators buy the same parallelism without giving up
the accuracy. Measured against the NEON version over 4096 distinct rows at dim 768:

NEON plain C, 4 accumulators
bits=2 25.60 26.60 Mvec/s
bits=3 9.48 9.55
bits=4 7.11 7.00

End to end over 40k rows all three bit widths land within noise of where they were.
bits=2 may be a few percent slower; the run-to-run spread is wider than the
difference, so I would not claim it either way.

The number that matters

Every backend now returns the same distance for the same query. Across 300 cases
spanning all three bit widths and dimensions 64 to 1536, SIMD-versus-scalar
divergence goes from 1.5e-4 relative to exactly zero. The old spread came from
accumulating in float over up to 384 terms while the scalar path used double,
so which distance you got depended on which CPU ran the query — enough to reorder
near-ties.

Five near-identical copies is also what let them drift apart in the first place.

A third copy of the same loop lived in sqlite-vector.c as a fallback for a null
dispatch pointer that init_distance_functions() always sets. Gone too.
Net 197 lines removed.

What would actually make this faster

A different storage layout — interleaving codes across vectors so the lookups
become in-register shuffles rather than memory gathers, the FastScan approach.
That changes the on-disk format, so it is a product decision rather than a patch.

Worth knowing while that decision is open: with the integer kernels fixed in #54,
TurboQuant is now slower than plain u8 quantization, over 40k rows at dim 768:

Mvec/s
u8 quantized, preloaded 62.6
TURBO2 21.7
TURBO3 7.2
TURBO4 (the default) 6.7

TURBO4 stores half the bytes of u8 and runs 9× slower, because it does 384 gathers
into a 393 KB table per row. That is a design trade-off, not a bug, and nothing
here changes it — but the space/time balance is steeper than the docs suggest, and
anyone picking TURBO4 expecting speed as well as compactness is getting the
opposite.

Note on the public API

vector_turboquant_backend() keeps returning exactly the strings it returns today.
What changed is that the value no longer identifies a TurboQuant-specific code path,
because there is only one now — it reports the SIMD tier selected at load time, the
same one vector_backend() reports. API.md now says that. If you would rather drop
the function, that is a separate call and I have not made it here.

Verification

  • Build clean on arm64, x86_64 and universal.
  • 1447/1447 on unittest, unittest-simd, and under ASan.
  • Every distance kernel still within tolerance against a double-precision reference.
  • Divergence sweep: 300 cases, SIMD vs scalar, exactly zero difference.

🤖 Generated with Claude Code

The audit listed this as "the LUT backends are not actually vectorised - each one
gathers four scalar table lookups into a stack array and does a single vector
add". That was accurate, but the conclusion was wrong: there is nothing for SIMD
to do here. The scan is one table lookup per row, NEON has no gather instruction
at all, and measured on real data the loop already runs at about one lookup per
cycle. What the per-backend versions were actually buying was four parallel
float lanes instead of one serial double accumulator - and four independent
double accumulators buy the same parallelism without giving up the accuracy.

So the five copies collapse into one. Plain C, four accumulators, double
throughout. Measured against the NEON version on 4096 distinct rows at dim 768:

    bits=2   25.60 -> 26.60 Mvec/s
    bits=3    9.48 ->  9.55
    bits=4    7.11 ->  7.00

End to end over 40k rows the three bit widths land within noise of where they
were; bits=2 may be a few percent slower, the run-to-run spread is wider than
the difference.

The point is the second number. Every backend now returns the same distance for
the same query: across 300 cases spanning all three bit widths and dimensions 64
to 1536, SIMD versus scalar divergence goes from 1.5e-4 relative to exactly
zero. The old spread came from accumulating in float over up to 384 terms while
the scalar path used double, so which distance you got depended on which CPU ran
the query - enough to reorder near-ties.

A third copy of the same loop lived in sqlite-vector.c as a fallback for a null
dispatch pointer that init_distance_functions() always sets. It is gone too.

Net 197 lines removed. What would actually make this scan faster is a different
storage layout - interleaving codes across vectors so the lookups become
in-register shuffles rather than memory gathers - which changes the on-disk
format and is not this change.

vector_turboquant_backend() keeps returning the same strings; API.md now
describes what it means, which is the SIMD tier selected at load time rather
than a TurboQuant-specific code path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@marcobambini
marcobambini merged commit a04576b into main Aug 24, 2026
17 checks passed
@marcobambini
marcobambini deleted the perf/unify-turboquant-lut branch August 24, 2026 21:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant